其他
Serverless + Egg.js 后台管理系统实战
The following article is from TencentServerless Author yugasun
Egg.js
是一个非常优秀的企业级框架,它的高扩展性和丰富的插件,极大的提高了开发效率。开发者只需要关注业务就好,比如要使用 redis
,引入 egg-redis 插件,然后简单配置就可以了。正因为如此,第一次接触它,我便喜欢上了它,之后也用它开发过不少应用。Egg.js
的服务迁移到 Serverless
架构上呢?背景
Vue.js
的前端应用和基于 Express
的后端服务,快速部署到腾讯云上。虽然受到不少开发者的喜爱,但是很多开发者私信问我,这还是一个 Demo
性质的项目而已,有没有更加实用性的解决方案。而且他们实际开发中,很多使用的正是 Egg.js
框架,能不能提供一个 Egg.js
的解决方案?Egg.js
和 Serverless
实现一个后台管理系统。Egg.js 基本使用 如何使用 Sequelize ORM 模块进行 Mysql 操作 如何使用 Redis 如何使用 JWT 进行用户登录验证 Serverless Framework 的基本使用 如何将本地开发好的 Egg.js 应用部署到腾讯云云函数上 如何基于云端对象存储快速部署静态网站
Egg.js 入门
$ npm init egg --type=simple
$ npm i
http://localhost:7001
,就可以看到亲切的 hi, egg
了。https://eggjs.org/zh-cn/intro/quickstart.html
准备
admin-system
:admin-system
目录下,重命名为 backend
。然后将前端模板项目复制到 frontend
文件夹中:说明:vue-admin-template 是基于 Vue2.0 的管理系统模板,是一个非常优秀的项目,建议对 Vue.js 感兴趣的开发者可以去学习下,当然如果你对 Vue.js 还不是太了解,这里有个基础入门学习教程 Vuejs 从入门到精通系列文章
├── README.md
├── backend // 创建的 Egg.js 项目
└── frontend // 克隆的 Vue.js 前端项目模板
$ npm install
$ npm run dev
http://localhost:9528
就可以看到登录界面了。开发后端服务
# 因为需要通过 sequelize 链接 mysql 所以这也同时安装 mysql2 模块
$ npm install egg-sequelize mysql2 --save
backend/config/plugin.js
中引入该插件:// ....
sequelize: {
enable: true,
package: "egg-sequelize"
}
// ....
};
backend/config/config.default.js
中配置数据库连接参数:const userConfig = {
// ...
sequelize: {
dialect: "mysql",
// 这里也可以通过 .env 文件注入环境变量,然后通过 process.env 获取
host: "xxx",
port: "xxx",
database: "xxx",
username: "xxx",
password: "xxx"
}
// ...
};
// ...
backend/app/model/role.js
文件如下:const { STRING, INTEGER, DATE } = app.Sequelize;
const Role = app.model.define("role", {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: STRING(30),
created_at: DATE,
updated_at: DATE
});
// 这里定义与 users 表的关系,一个角色可以含有多个用户,外键相关
Role.associate = () => {
app.model.Role.hasMany(app.model.User, { as: "users" });
};
return Role;
};
backend/app/service/role.js
文件如下:class RoleService extends Service {
// 获取角色列表
async list(options) {
const {
ctx: { model }
} = this;
return model.Role.findAndCountAll({
...options,
order: [
["created_at", "desc"],
["id", "desc"]
]
});
}
// 通过 id 获取角色
async find(id) {
const {
ctx: { model }
} = this;
const role = await model.Role.findByPk(id);
if (!role) {
this.ctx.throw(404, "role not found");
}
return role;
}
// 创建角色
async create(role) {
const {
ctx: { model }
} = this;
return model.Role.create(role);
}
// 更新角色
async update({ id, updates }) {
const role = await this.ctx.model.Role.findByPk(id);
if (!role) {
this.ctx.throw(404, "role not found");
}
return role.update(updates);
}
// 删除角色
async destroy(id) {
const role = await this.ctx.model.Role.findByPk(id);
if (!role) {
this.ctx.throw(404, "role not found");
}
return role.destroy();
}
}
module.exports = RoleService;
RoleController
, 创建 backend/app/controller/role.js
:class RoleController extends Controller {
async index() {
const { ctx } = this;
const { query, service, helper } = ctx;
const options = {
limit: helper.parseInt(query.limit),
offset: helper.parseInt(query.offset)
};
const data = await service.role.list(options);
ctx.body = {
code: 0,
data: {
count: data.count,
items: data.rows
}
};
}
async show() {
const { ctx } = this;
const { params, service, helper } = ctx;
const id = helper.parseInt(params.id);
ctx.body = await service.role.find(id);
}
async create() {
const { ctx } = this;
const { service } = ctx;
const body = ctx.request.body;
const role = await service.role.create(body);
ctx.status = 201;
ctx.body = role;
}
async update() {
const { ctx } = this;
const { params, service, helper } = ctx;
const body = ctx.request.body;
const id = helper.parseInt(params.id);
ctx.body = await service.role.update({
id,
updates: body
});
}
async destroy() {
const { ctx } = this;
const { params, service, helper } = ctx;
const id = helper.parseInt(params.id);
await service.role.destroy(id);
ctx.status = 200;
}
}
module.exports = RoleController;
backend/app/route.js
路由配置文件中定义 role
的 RESTful API:router.resources
方法,我们将 roles
这个资源的增删改查接口映射到了 app/controller/roles.js
文件。https://eggjs.org/zh-cn/tutorials/restful.html
Role
和 User
两个 Schema,那么如何同步到数据库呢?这里先借助 Egg.js 启动的 hooks 来实现,Egg.js 框架提供了统一的入口文件(app.js)进行启动过程自定义,这个文件返回一个 Boot 类,我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。backend
目录中创建 app.js
文件,如下:class AppBootHook {
constructor(app) {
this.app = app;
}
async willReady() {
// 这里只能在开发模式下同步数据库表格
const isDev = process.env.NODE_ENV === "development";
if (isDev) {
try {
console.log("Start syncing database models...");
await this.app.model.sync({ logging: console.log, force: isDev });
console.log("Start init database data...");
await this.app.model.query(
"INSERT INTO roles (id, name, created_at, updated_at) VALUES (1, 'admin', '2020-02-04 09:54:25', '2020-02-04 09:54:25'),(2, 'editor', '2020-02-04 09:54:30', '2020-02-04 09:54:30');"
);
await this.app.model.query(
"INSERT INTO users (id, name, password, age, avatar, introduction, created_at, updated_at, role_id) VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 20, 'https://yugasun.com/static/avatar.jpg', 'Fullstack Engineer', '2020-02-04 09:55:23', '2020-02-04 09:55:23', 1);"
);
await this.app.model.query(
"INSERT INTO posts (id, title, content, created_at, updated_at, user_id) VALUES (2, 'Awesome Egg.js', 'Egg.js is a awesome framework', '2020-02-04 09:57:24', '2020-02-04 09:57:24', 1),(3, 'Awesome Serverless', 'Build web, mobile and IoT applications using Tencent Cloud and API Gateway, Tencent Cloud Functions, and more.', '2020-02-04 10:00:23', '2020-02-04 10:00:23', 1);"
);
console.log("Successfully init database data.");
console.log("Successfully sync database models.");
} catch (e) {
console.log(e);
throw new Error("Database migration failed.");
}
}
}
}
module.exports = AppBootHook;
willReady
生命周期函数,我们可以执行 this.app.model.sync()
函数来同步数据表,当然这里同时初始化了角色和用户数据记录,用来做为演示用。注意:这的数据库同步只是本地调试用,如果想要腾讯云的 Mysql 数据库,建议开启远程连接,通过 sequelize db:migrate
实现,而不是每次启动 Egg 应用时同步,示例代码已经完成此功能,参考 Egg Sequelize 文档。这里本人为了省事,直接开启腾讯云 Mysql 公网连接,然后修改config.default.js
中的sequelize
配置,运行npm run dev
进行开发模式同步。
npm run dev
,访问 https://127.0.0.1:7001/users
可以获取所有用户列表了。用户名
和 密码
到 /login
路由,后端通过 login
函数接受,然后从数据库中查询该用户名,同时比对密码是否正确。如果正确则调用 app.jwt.sign()
函数生成 token
,并将 token
存入到 redis
中,同时返回该 token
,之后客户端需要鉴权的请求都会携带 token
,进行鉴权验证。思路很简单,我们就开始实现了。backend/app/controller/home.js
中新增登录处理 login
方法:// ...
async login() {
const { ctx, app, config } = this;
const { service, helper } = ctx;
const { username, password } = ctx.request.body;
const user = await service.user.findByName(username);
if (!user) {
ctx.status = 403;
ctx.body = {
code: 403,
message: "Username or password wrong"
};
} else {
if (user.password === helper.encryptPwd(password)) {
ctx.status = 200;
const token = app.jwt.sign(
{
id: user.id,
name: user.name,
role: user.role.name,
avatar: user.avatar
},
config.jwt.secret,
{
expiresIn: "1h"
}
);
try {
await app.redis.set(`token_${user.id}`, token);
ctx.body = {
code: 0,
message: "Get token success",
token
};
} catch (e) {
console.error(e);
ctx.body = {
code: 500,
message: "Server busy, please try again"
};
}
} else {
ctx.status = 403;
ctx.body = {
code: 403,
message: "Username or password wrong"
};
}
}
}
}
注释:这里有个密码存储逻辑,用户在注册时,密码都是通过 helper
函数encryptPwd()
进行加密的(这里用到最简单的 md5 加密方式,实际开发中建议使用更加高级加密方式),所以在校验密码正确性时,也需要先加密一次。至于如何在 Egg.js 框架中新增helper
函数,只需要在backend/app/extend
文件夹中新增helper.js
文件,然后modole.exports
一个包含该函数的对象就行,参考 Egg 框架扩展文档
backend/app/controller/home.js
中新增 userInfo
方法,获取用户信息:const { ctx } = this;
const { user } = ctx.state;
ctx.status = 200;
ctx.body = {
code: 0,
data: user,
};
}
app.jwt.sign(user, secrete)
加密的用户信息,添加到 ctx.state.user
中,所以 userInfo
函数只需要将它返回就行。backend/app/controller/home.js
中新增 logout
方法:const { ctx } = this;
ctx.status = 200;
ctx.body = {
code: 0,
message: 'Logout success',
};
}
userInfo
和 logout
函数非常简单,重点是路由中间件如何处理。backend/app/router.js
文件,新增 /login
, /user-info
, /logout
三个路由:module.exports = app => {
const { router, controller, jwt } = app;
router.get("/", controller.home.index);
router.post("/login", controller.home.login);
router.get("/user-info", jwt, controller.home.userInfo);
const isRevokedAsync = function(req, payload) {
return new Promise(resolve => {
try {
const userId = payload.id;
const tokenKey = `token_${userId}`;
const token = app.redis.get(tokenKey);
if (token) {
app.redis.del(tokenKey);
}
resolve(false);
} catch (e) {
resolve(true);
}
});
};
router.post(
"/logout",
koajwt({
secret: app.config.jwt.secret,
credentialsRequired: false,
isRevoked: isRevokedAsync
}),
controller.home.logout
);
router.resources("roles", "/roles", controller.role);
router.resources("users", "/users", controller.user);
router.resources("posts", "/posts", controller.post);
};
router.post()
函数可以接受中间件函数,用来处理一些路由相关的特殊逻辑。/user-info
,路由添加了 app.jwt
作为 JWT 鉴权中间件函数,至于为什么这么用,egg-jwt 插件有明确说明。/logout
路由,因为我们在注销登录时,需要将用户的 token
从 redis
中移除,所以这里借助了 koa-jwt2 的 isRevokded
参数,来进行 token
删除。后端服务部署
backend/sls.js
入口文件:const app = new Application();
module.exports = app;
backend/config/config.default.js
文件:env: "prod", // 推荐云函数的 egg 运行环境变量修改为 prod
rundir: "/tmp",
logger: {
dir: "/tmp"
}
});
注释:这里之所有需要修改运行和日志目录,是因为云函数运行时,只有 /tmp
才有写权限。
serverless
命令:serverless.yml
文件,同时新增 backend
配置:component: "@serverless/tencent-egg"
inputs:
code: ./backend
functionName: admin-system
# 这里必须指定一个具有操作 mysql 和 redis 的角色,具体角色创建,可访问 https://console.cloud.tencent.com/cam/role
role: QCS_SCFFull
functionConf:
timeout: 120
# 这里的私有网络必须和 mysql、redis 实例一致
vpcConfig:
vpcId: vpc-xxx
subnetId: subnet-xxx
apigatewayConf:
protocols:
- https
├── README.md // 项目说明文件
├── serverless.yml // serverless yml 配合文件
├── backend // 创建的 Egg.js 项目
└── frontend // 克隆的 Vue.js 前端项目模板
region: ap-guangzhou
functionName: admin-system
apiGatewayServiceId: service-f1bhmhk4
url: https://service-f1bhmhk4-1251556596.gz.apigw.tencentcs.com/release/
注释:云函数部署时,会自动在腾讯云的 API 网关创建一个服务,同时创建一个 API,通过该 API 就可以触发云函数执行了。
.env
文件.env
文件中配置腾讯云的 SecretId 和 SecretKey 信息并保存,密钥可以在 API 密钥管理 中获取或者创建.TENCENT_SECRET_ID=123
TENCENT_SECRET_KEY=123
posts
, 并修改数据模型就行,这里就不粘贴代码了。前端开发
删除接口模拟:更换为真实的后端服务接口 修改接口函数:包括用户相关的 frontend/src/api/user.js
和文章相关接口frontend/src/api/post.js
。修改接口工具函数:主要是修改 frontend/src/utils/request.js
文件,包括axios
请求的baseURL
和请求的 header。UI 界面修改:主要是新增文章管理页面,包括列表页和新增页。
frontend/mock
文件夹。然后修改前端入口文件 frontend/src/main.js
:import "./env.js";
import Vue from "vue";
import "normalize.css/normalize.css";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
import locale from "element-ui/lib/locale/lang/en";
import "@/styles/index.scss";
import App from "./App";
import store from "./store";
import router from "./router";
import "@/icons";
import "@/permission";
// 2. 下面这段就是 mock server 引入,删除就好
// if (process.env.NODE_ENV === 'production') {
// const { mockXHR } = require('../mock')
// mockXHR()
// }
Vue.use(ElementUI, { locale });
Vue.config.productionTip = false;
new Vue({
el: "#app",
router,
store,
render: h => h(App)
});
frontend/src/api/user.js
文件,包括登录、注销、获取用户信息和获取用户列表函数如下:// 登录
export function login(data) {
return request({
url: "/login",
method: "post",
data
});
}
// 获取用户信息
export function getInfo(token) {
return request({
url: "/user-info",
method: "get"
});
}
// 注销登录
export function logout() {
return request({
url: "/logout",
method: "post"
});
}
// 获取用户列表
export function getList() {
return request({
url: "/users",
method: "get"
});
}
frontend/src/api/post.js
文件如下:// 获取文章列表
export function getList(params) {
return request({
url: "/posts",
method: "get",
params
});
}
// 创建文章
export function create(data) {
return request({
url: "/posts",
method: "post",
data
});
}
// 删除文章
export function destroy(id) {
return request({
url: `/posts/${id}`,
method: "delete"
});
}
@serverless/tencent-website
组件可以定义 env
参数,执行成功后它会在指定 root
目录自动生成 env.js
,然后在 frontend/src/main.js
中引入使用。它会挂载 env
中定义的接口变量到 window
对象上。比如这生成的 env.js
文件如下:window.env.apiUrl =
"https://service-f1bhmhk4-1251556596.gz.apigw.tencentcs.com/release/";
frontend/src/utils/request.js
文件:import { MessageBox, Message } from "element-ui";
import store from "@/store";
import { getToken } from "@/utils/auth";
// 创建 axios 实例
const service = axios.create({
// 1. 这里设置为 `env.js` 中的变量 `window.env.apiUrl`
baseURL: window.env.apiUrl || "/", // url = base url + request url
timeout: 5000 // request timeout
});
// request 注入
service.interceptors.request.use(
config => {
// 2. 添加鉴权token
if (store.getters.token) {
config.headers["Authorization"] = `Bearer ${getToken()}`;
}
return config;
},
error => {
console.log(error); // for debug
return Promise.reject(error);
}
);
// 请求 response 注入
service.interceptors.response.use(
response => {
const res = response.data;
// 只有请求code为0,才是正常返回,否则需要提示接口错误
if (res.code !== 0) {
Message({
message: res.message || "Error",
type: "error",
duration: 5 * 1000
});
if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// to re-login
MessageBox.confirm(
"You have been logged out, you can cancel to stay on this page, or log in again",
"Confirm logout",
{
confirmButtonText: "Re-Login",
cancelButtonText: "Cancel",
type: "warning"
}
).then(() => {
store.dispatch("user/resetToken").then(() => {
location.reload();
});
});
}
return Promise.reject(new Error(res.message || "Error"));
} else {
return res;
}
},
error => {
console.log("err" + error);
Message({
message: error.message,
type: "error",
duration: 5 * 1000
});
return Promise.reject(error);
}
);
export default service;
Star (*^▽^*)
frontend/router
和 frontend/views
两个文件夹就好。前端部署
serverless.yml
文件,新增前端相关配置:# 前端配置
frontend:
component: "@serverless/tencent-website"
inputs:
code:
src: dist
root: frontend
envPath: src # 相对于 root 指定目录,这里实际就是 frontend/src
hook: npm run build
env:
# 依赖后端部署成功后生成的 url
apiUrl: ${backend.url}
protocol: https
# TODO: CDN 配置,请修改!!!
hosts:
- host: sls-admin.yugasun.com # CDN 加速域名
https:
certId: abcdedg # 为加速域名在腾讯云平台申请的免费证书 ID
http2: off
httpsType: 4
forceSwitch: -2
# 后端配置
backend:
component: "@serverless/tencent-egg"
inputs:
code: ./backend
functionName: admin-system
role: QCS_SCFFull
functionConf:
timeout: 120
vpcConfig:
vpcId: vpc-6n5x55kb
subnetId: subnet-4cvr91js
apigatewayConf:
protocols:
- https
url: https://dtnu69vl-470dpfh-1251556596.cos-website.ap-guangzhou.myqcloud.com
env:
apiUrl: https://service-f1bhmhk4-1251556596.gz.apigw.tencentcs.com/release/
host:
- https://sls-admin.yugasun.com (CNAME: sls-admin.yugasun.com.cdn.dnsv1.com)
backend:
region: ap-guangzhou
functionName: admin-system
apiGatewayServiceId: service-f1bhmhk4
url: https://service-f1bhmhk4-1251556596.gz.apigw.tencentcs.com/release/
注释:这里 frontend
中多输出了host
,是我们的 CDN 加速域名,可以通过配置@serverless/tencent-website
组件的inputs.hosts
来实现。有关 CDN 相关配置说明可以阅读 基于 Serverless Component 的全栈解决方案 - 续集。当然,如果你不想配置 CDN,直接删除,然后访问 COS 生成的静态网站 url。
https://sls-admin.yugasun.com
登录体验了。总结
frontend
更换为任何你喜欢的前端框架项目,开发时只需要将接口请求前缀使用 @serverless/tencent-website
组件生成的 env.js
文件就行。传送门:
GitHub: github.com/serverless
官网:serverless.com
敬请关注「Nodejs技术栈」微信公众号,获取优质文章